Angular

Custom Standalone APIs for Angular

Explore Angular's standalone APIs and valuable patterns from popular libraries like HttpClient, Router, and NgRx in this article.

Manfred Steyer

Together with standalone components, the Angular team has introduced the so-called standalone APIs. They provide a simple solution for library setup and do not require Angular modules. Popular libraries that already implement this concept include the HttpClient, Router, and NgRx. These libraries are based on several patterns that we find beneficial in our own projects. They also provide our library users with familiar structures and behaviors. In this article, I show three such patterns that I derived from the libraries mentioned.
The source code and examples are available here.

Example

A simple logger library is used here to show the different patterns (Fig. 1). The LogFormatter formats the messages before the Logger publishes them. This is an abstract class that is used as a DI token. The consumers of the logger library can customize the formatting by providing their own implementation. Alternatively, they can settle for a default implementation provided by the library.

Fig. 1

Fig. 1: Structure of an exemplary Logger library

The LogAppender is another replaceable concept that takes care of attaching the message to a log. The default implementation just writes the message to the console.

iJS Newsletter

Keep up with JavaScript’s latest news!

While there can be only one LogFormatter, the library supports multiple LogAppenders. For example, the first LogAppender might write the message to the console, while the second also sends it to the server. To make this possible, each LogAppender is registered via a multiprovider. The injector returns all registered LogAppenders in the form of an array. Since an array cannot be used as a DI token, the example uses an InjectionToken instead:

export const LOG_APPENDERS =
  new InjectionToken<LogAppender[]>("LOG_APPENDERS");

An abstract LoggerConfig, which also acts as a DI token, defines the possible configuration options (Listing 1).

Listing 1

export abstract class LoggerConfig {
  abstract level: LogLevel;
  abstract formatter: Type<LogFormatter>;
  abstract appenders: Type<LogAppender>[];
}
 
export const defaultConfig: LoggerConfig = {
  level: LogLevel.DEBUG,
  formatter: DefaultLogFormatter,
  appenders: [DefaultLogAppender],
};

The default values for these configuration options are in the defaultConfig constant. The LogLevel in the configuration is a filter for log messages. It is of type enum and has for simplification only the values DEBUG, INFO and ERROR:

export enum LogLevel {
  DEBUG = 0,
  INFO = 1,
  ERROR = 2,
}

The Logger only publishes messages that have the LogLevel specified here or a higher LogLevel. The LoggerService itself receives the LoggerConfig, the LogFormatter and an array with LogAppender via DI and uses them to log the received messages (Listing 2).

Listing 2

@Injectable()
export class LoggerService {
  private config = inject(LoggerConfig);
  private formatter = inject(LogFormatter);
  private appenders = inject(LOG_APPENDERS);
 
  log(level: LogLevel, category: string, msg: string): void {
    if (level < this.config.level) {
      return;
    }
    const formatted = this.formatter.format(level, category, msg);
    for (const a of this.appenders) {
      a.append(level, category, formatted);
    }
  }
 
  error(category: string, msg: string): void {
    this.log(LogLevel.ERROR, category, msg);
  }
 
  info(category: string, msg: string): void {
    this.log(LogLevel.INFO, category, msg);
  }
 
  debug(category: string, msg: string): void {
    this.log(LogLevel.DEBUG, category, msg);
  }
}

The golden rule

Before we take a look at the patterns, I want to mention my golden rule for registering services: Use @Injectable({providedIn: ‘root’}) whenever possible! Especially in applications, but also in numerous situations in libraries, this approach is perfectly sufficient. It is simple, treeshakable and even works with lazy loading. The latter aspect is less a merit of Angular than of the underlying bundler. Everything that can only be used in a lazy bundle is also accommodated there.

EVERYTHING AROUND ANGULAR

The iJS Angular track

Pattern: provider factory

A provider factory is a function that returns all services for a reusable library. It can also register configuration objects as services or exchange service implementations.

The returned services are in a provider array that wraps the factory with the EnvironmentProviders type. This approach, designed by the Angular team, ensures that an application can register providers only with so-called environment injectors. These are primarily the injector for the root scope and injectors that Angular sets up via the routing configuration. The provider factory in Listing 3 illustrates this. It takes a LoggerConfig and sets up the individual services for the Logger.

Listing 3

export function provideLogger(
  config: Partial<LoggerConfig>
): EnvironmentProviders {
  // using default values for   // missing properties
  const merged = { ...defaultConfig, ...config };
 
  return makeEnvironmentProviders([
    {
      provide: LoggerConfig,
      useValue: merged,
    },
    {
      provide: LogFormatter,
      useClass: merged.formatter,
    },
    merged.appenders.map((a) => ({
      provide: LOG_APPENDERS,
      useClass: a,
      multi: true,
    })),
  ]);
}

The factory takes missing configuration values from the default configuration. The makeEnvironmentProviders function provided by Angular wraps the provider array into an instance of EnvironmentProviders. This factory allows consumers to set up the logger similarly to how they set up the HttpClient or router (Listing 4).

Listing 4

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(),
    provideRouter(APP_ROUTES),
    [...]
    provideLogger(loggerConfig),
  ]
}

Pattern: feature

The feature pattern allows optional functionality to be enabled and configured. If this functionality is not used, the build process removes it using treeshaking. The optional feature is represented by an object with a providers array. In addition, the object has a kind property that subdivides the feature of a certain category. This categorization enables the validation of the jointly configured features. For example, features can be mutually exclusive. An example of this can be found in the HttpClient: It prohibits the use of a feature for configuring XSRF handling if the consumers have simultaneously activated a feature for disabling it.

The logger library used here uses a ColorFeature that allows messages to be output in different colors depending on the LoggerLevel (Fig. 2).

Fig. 2

Fig. 2: Structure of the ColorFeature

An enum is used to categorize features:

export enum LoggerFeatureKind {
  COLOR,
  OTHER_FEATURE,
  ADDITIONAL_FEATURE
}

Another factory is used to provide the ColorFeature (Listing 5).

Listing 5

export function withColor(config?: Partial<ColorConfig>): LoggerFeature {
  const internal = { ...defaultColorConfig, ...config };
 
  return {
    kind: LoggerFeatureKind.COLOR,
    providers: [
      {
        provide: ColorConfig,
        useValue: internal,
      },
      {
        provide: ColorService,
        useClass: DefaultColorService,
      },
    ],
  };
}

The updated provider factory provideLogger takes on several features via an optional second parameter defined as an array for rest parameters (Listing 6).

Listing 6

export function provideLogger(
  config: Partial<LoggerConfig>,
  ...features: LoggerFeature[]
): EnvironmentProviders {
  const merged = { ...defaultConfig, ...config };
 
  // Inspecting passed features
  const colorFeatures =
    features?.filter((f) => f.kind === LoggerFeatureKind.COLOR)?.length ?? 0;
 
  // Validating passed features
  if (colorFeatures > 1) {
    throw new Error("Only one color feature allowed for logger!");
  }
 
  return makeEnvironmentProviders([
    {
      provide: LoggerConfig,
      useValue: merged,
    },
    {
      provide: LogFormatter,
      useClass: merged.formatter,
    },
    merged.appenders.map((a) => ({
      provide: LOG_APPENDERS,
      useClass: a,
      multi: true,
    })),
 
    // Providing services for the     // features
    features?.map((f) => f.providers),
  ]);
}

The provider factory uses the kind property to examine and validate the passed features. If all is well, it includes the feature’s providers in the EnvironmentProviders object. The DefaultLogAppender fetches the ColorService provided by the ColorFeature via dependency injection (Listing 7).

Listing 7

export class DefaultLogAppender implements LogAppender {
  colorService = inject(ColorService, { optional: true });
 
  append(level: LogLevel, category: string, msg: string): void {
    if (this.colorService) {
      msg = this.colorService.apply(level, msg);
    }
    console.log(msg);
  }
}

Since features are optional, the DefaultLog appender passes the {optional: true} option to inject. This prevents an exception in cases where the feature, and thus the ColorService, has not been provided. Also, the DefaultLogAppender must check for null values.

This pattern occurs in the router, e.g. to configure preloading or to enable tracing. The HttpClient uses it to provide interceptors, to configure JSONP and to configure/disable XSRF token handling.

Pattern: configuration factory

Configuration factories extend the behavior of existing services. They can provide additional configuration options, but also additional services. An extended version of our LoggerService will serve as an illustration. It allows to define an additional LogAppender for each log category:

@Injectable()
export class LoggerService {
  readonly categories: Record<string, LogAppender> = {};
  […]
}

To configure a LogAppender for a category, we introduce a configuration factory named provideCategory (Listing 8).

Listing 8

export function provideCategory(
  category: string,
  appender: Type<LogAppender>
): EnvironmentProviders {
  // Internal/ Local token for registering the service
  // and retrieving the resolved service instance
  // immediately after.
  const appenderToken = new InjectionToken<LogAppender>("APPENDER_" + category);
 
  return makeEnvironmentProviders([
    {
      provide: appenderToken,
      useClass: appender,
    },
    {
      provide: ENVIRONMENT_INITIALIZER,
      multi: true,
      useValue: () => {
        const appender = inject(appenderToken);
        const logger = inject(LoggerService);
 
        logger.categories[category] = appender;
      },
    },
  ]);
}

This factory creates a provider for the LogAppender class. The call to inject gives us an instance of it and resolves its dependencies. The ENVIRONMENT_INITIALIZER token points to a function that Angular triggers when initializing the respective environment injector. It registers the LogAppender with the LoggerService (Listing 9).

Listing 9

export const FLIGHT_BOOKING_ROUTES: Routes = [
 
  {
    path: '',
    component: FlightBookingComponent,
    providers: [
      // Setting up an NgRx      // feature slice
      provideState(bookingFeature),
      provideEffects([BookingEffects]),
 
      // Provide LogAppender for      // logger category
      provideCategory('booking', DefaultLogAppender),
    ],
    children: [
      {
        path: 'flight-search',
        component: FlightSearchComponent,
      },
      [...]
    ],
  },
];

This pattern is found, for example, in NgRx to register feature slices. The feature withDebugTracing offered by the router also uses this pattern to subscribe to the observable events in the router service.

Conclusion

Standalone APIs allow you to set up libraries without Angular modules. Their use is simple to begin with: consumers simply need to look for a provider factory with the name provideXYZ. Additional features can be enabled, if necessary, with functions that follow the withABC naming scheme.

However, the implementation of such APIs is not always trivial. This is exactly where the patterns presented here help. Since they are derived from libraries of the Angular and NgRx teams, they reflect first-hand experience and design decisions.

Top Articles About Angular

Sign up for the iJS newsletter and stay tuned to the latest JavaScript news!

 

BEHIND THE TRACKS OF iJS

JavaScript Practices & Tools

DevOps, Testing, Performance, Toolchain & SEO

Angular

Best-Practises with Angular

General Web Development

Broader web development topics

Node.js

All about Node.js

React

From Basic concepts to unidirectional data flows

DON'T MISS ANY NEWS